package com.nulldev.util.web.YouTubeExtractor.signatures;

import static com.nulldev.util.web.YouTubeExtractor.util.YouTubeExtractorUtil.validateFunctionName;

import java.io.InterruptedIOException;
import java.net.ConnectException;
import java.time.Duration;
import java.util.regex.Pattern;

import com.nulldev.util.JVM.Arguments;
import com.nulldev.util.JVM.JVM;
import com.nulldev.util.VariableAPI.ArrayUtils;
import com.nulldev.util.VariableAPI.RandomUtil;
import com.nulldev.util.VariableAPI.StringsUtil;
import com.nulldev.util.concurrency.threadIt.v4.emapi.ExecutorManager;
import com.nulldev.util.data.Variables;
import com.nulldev.util.data.variable;
import com.nulldev.util.io.file.FileUtil;
import com.nulldev.util.logging.Logger;
import com.nulldev.util.logging.LoggerFactory;
import com.nulldev.util.scripting.ScriptAPI.nScripting;
import com.nulldev.util.web.HttpClient.HttpClient;
import com.nulldev.util.web.HttpClient.HttpResponse;
import com.nulldev.util.web.URLUtils.Entry;
import com.nulldev.util.web.URLUtils.fuckTracking.FuckTracking;
import com.nulldev.util.web.YouTubeExtractor.YouTubeExtractor;
import com.nulldev.util.web.YouTubeExtractor.YouTubeExtractorConstants;
import com.nulldev.util.web.YouTubeExtractor.YoutubeExtractionOptions;
import com.nulldev.util.web.YouTubeExtractor.signatures.extractor.Extractor;
import com.nulldev.util.web.YouTubeExtractor.signatures.extractors.BaseJSExtractor;
import com.nulldev.util.web.YouTubeExtractor.signatures.extractors.GraalBasedExtractor;
import com.nulldev.util.web.YouTubeExtractor.signatures.extractors.NashornBasedExtractor;
import com.nulldev.util.web.YouTubeExtractor.signatures.extractors.RhinoBasedExtractor;
import com.nulldev.util.web.YouTubeExtractor.signatures.extractors.SoftwareExtractor;

public class SignatureExtractor {
	private static final Logger logger = LoggerFactory.getLoggerD(Arguments.hasArgument("--debug", "-debug", "--trace", "--SignatureExtractor.debug"), true);
	private static final boolean USE_OLD_DEFER_LOGIC = Arguments.hasArgument("--SignatureExtractor.oldDeferLogic");
	private static final boolean CLEAN_URL_CHARS = Arguments.hasArgument("--SignatureExtractor.cleanUrlChars", "--SignatureUpdates.cleanUrlChars");

	/**
	 * Cipher Versions:
	 * 
	 * 6.0 (nTube 1.0) Removed in nTube 1.4 <br>
	 * 7.0 (nTube 1.4) Removed in nTube 1.4.4 <br>
	 * 8.0 (nTube 1.4.4/1.5-dev) Active <br >
	 * 9.0 (nTube 1.5-rc3, used in URLs) Active
	 * 
	 * Next Internal Version:
	 * 
	 * 9.1
	 */

	private static final String CACHED_PATTERN = Pattern.compile("\\s").pattern();
	static final variable<String> cachedPlayerURL = new variable<String>();

	private static String cleanInvalidCharacters(String in) {
		if (in == null)
			return null;
		if (in.isEmpty())
			return in;
		final StringBuilder out = new StringBuilder();
		for (int i = 0; i < in.length(); i++) {
			final int current = in.charAt(i);
			if ((current == 0x9) || (current == 0xA) || (current == 0xD) || ((current >= 0x20) && (current <= 0xD7FF))
					|| ((current >= 0xE000) && (current <= 0xFFFD)) || ((current >= 0x10000) && (current <= 0x10FFFF))) {
				out.append(current);
			}
		}
		return out.toString().replaceAll(CACHED_PATTERN, " ");
	}

	public static ExtractedSignatureData autoVideoID(final String videoID, final YoutubeExtractionOptions... options) throws Exception {
		logger.debug("[trace->autoVideoID] call");
		Entry[] he = new Entry[0];
		logger.debug("[trace->autoVideoID] he MADE");
		if (ArrayUtils.contains(options, YoutubeExtractionOptions.ENFORCE_PRIVACY_MODE)) {
			he = ArrayUtils.add(he, new Entry("Cookie", StringsUtil.randomString(64)));
			logger.debug("[trace->autoVideoID] he ADD COOKIE");
			he = ArrayUtils.addAll(he, FuckTracking.protect());
			logger.debug("[trace->autoVideoID] he FT ADD");
		} else if (ArrayUtils.contains(options, YoutubeExtractionOptions.EMULATE_INSECURE_PROXY)) {
			he = ArrayUtils.addAll(he, FuckTracking.protect());
			logger.debug("[trace->autoVideoID] he FT ADD");
		}
		final StringBuilder s = new StringBuilder();
		logger.debug("[trace->autoVideoID] StringBuilder::new");
		if (ArrayUtils.contains(options, YoutubeExtractionOptions.ADD_CONTROVERSY_BYPASS)) {
			s.append("&bpctr=" + System.currentTimeMillis());
			logger.debug("[trace->autoVideoID] S ADD ACB");
		}
		HttpResponse<String> _result;
		logger.debug("[trace->autoVideoID] _result declare");
		do {
			try {
				_result = HttpClient.getString().url(videoID == null ? "https://www.youtube.com/" : "https://www.youtube.com/watch?v=" + videoID + s.toString())
						.headers(he).timeout(Duration.ofSeconds(20)).headers(YouTubeExtractor.DEFAULT_COOKIES).create();
				logger.debug("[trace->autoVideoID] _result create");
				break;
			} catch (Exception e) {
				if (e instanceof InterruptedIOException) {
					// FIXME: No clue why this is thrown either way, just retry.
					try {
						Thread.sleep(50L);
					} catch (Exception e1) {
						Thread.interrupted();
					}
				} else {
					logger.error("autoVideoID(): Encountered an error, trying again.", e);
					try {
						final boolean isCE = e instanceof ConnectException;
						Thread.sleep(RandomUtil.randLong(isCE ? 500L : 50L, 60250L));
					} catch (Exception e1) {
						Thread.interrupted();
					}
				}
			}
		} while (true);
		final HttpResponse<String> result = _result;
		logger.debug("[trace->autoVideoID] result fetch");
		if (!result.successful())
			throw new RuntimeException("fromVideoID: Failed to get OK result! [got: " + result.status() + "]");
		logger.debug("[trace->autoVideoID] result SUCCESSFUL");
		final String r = result.dataThenClose();
		logger.debug("[trace->autoVideoID] result READ AND CLOSED");
		final String playerURL = getPlayerURL(r, options);
		logger.debug("autoVideoID-deadlock-fix:playerURL: " + playerURL);
		cachedPlayerURL.set(playerURL);
		logger.debug("autoVideoID-deadlock-fix: set cachedPlayerURL!");
		if (USE_OLD_DEFER_LOGIC) {
			ExecutorManager.globalAsyncSubmit(() -> {
				YouTubeExtractorConstants.auto(r);
				return null;
			});
		}
		if (YouTubeExtractor.extractor.get() != null && SignatureUpdates.getCachedPlayerURL() != null
				&& SignatureUpdates.getCachedPlayerURL().equalsIgnoreCase(playerURL)) {
			logger.info("Deferred signature decipherer update, already up to date.");
			return YouTubeExtractor.extractor.get().getExtractedData();
		} else if (SignatureUpdates.getCachedPlayerURL() == null) {
			logger.warn("PLAYER_URL missing, accquiring decipherer!");
			logger.debug("CURRENT_PLAYER_URL: " + SignatureUpdates.getCachedPlayerURL());
			logger.debug("PLAYER_URL: " + playerURL);
			SignatureUpdates.setCachedPlayerURL(playerURL);
		} else {
			logger.debug("Set CURRENT_PLAYER_URL to '" + playerURL + "'.");
			SignatureUpdates.setCachedPlayerURL(playerURL);
		}
		return fromData(r, playerURL, options);
	}

	public static ExtractedSignatureData fromVideoID(final String videoID, final YoutubeExtractionOptions... options) throws Exception {
		Entry[] he = new Entry[0];
		if (ArrayUtils.contains(options, YoutubeExtractionOptions.ENFORCE_PRIVACY_MODE)) {
			he = ArrayUtils.add(he, new Entry("Cookie", StringsUtil.randomString(64)));
			he = ArrayUtils.addAll(he, FuckTracking.protect());
		} else if (ArrayUtils.contains(options, YoutubeExtractionOptions.EMULATE_INSECURE_PROXY)) {
			he = ArrayUtils.addAll(he, FuckTracking.protect());
		}
		String s = "";
		if (ArrayUtils.contains(options, YoutubeExtractionOptions.ADD_CONTROVERSY_BYPASS)) {
			s += "&bpctr=" + System.currentTimeMillis();
		}
		final HttpResponse<String> result = HttpClient.getString().url("https://www.youtube.com/watch?v=" + videoID + s).headers(he)
				.headers(YouTubeExtractor.DEFAULT_COOKIES).create();
		if (!result.successful())
			throw new RuntimeException("fromVideoID: Failed to get OK result! [got: " + result.status() + "]");
		return fromData(result.dataThenClose(), null, options);
	}

	public static ExtractedSignatureData fromData(final String page, String playerURL, final YoutubeExtractionOptions... options) throws Exception {
		if (playerURL == null || playerURL.isEmpty())
			playerURL = getPlayerURL(page, options);
		Entry[] he = new Entry[0];
		if (ArrayUtils.contains(options, YoutubeExtractionOptions.ENFORCE_PRIVACY_MODE)) {
			he = ArrayUtils.add(he, new Entry("Cookie", StringsUtil.randomString(64)));
			he = ArrayUtils.addAll(he, FuckTracking.protect());
		} else if (ArrayUtils.contains(options, YoutubeExtractionOptions.EMULATE_INSECURE_PROXY)) {
			he = ArrayUtils.addAll(he, FuckTracking.protect());
		}
		final HttpResponse<String> result = HttpClient.getString().url(playerURL).headers(he).timeout(Duration.ofSeconds(300)) /* PROJECT_SLOW_CONN */
				.create();
		if (!result.successful())
			throw new RuntimeException("fromData: Failed to get OK result! [got: " + result.status() + "]");
		logger.info("[v8] player size: " + FileUtil.convertToStringRepresentation(result.length(true)));
		if (!USE_OLD_DEFER_LOGIC) {
			ExecutorManager.globalAsyncSubmit(() -> {
				YouTubeExtractorConstants.auto(page);
				return null;
			});
		}
		return fromJSData(result.dataThenClose());
	}

	private static final String PLAYER_URL_PATTERN1 = Pattern.quote("\\/");

	public static String getPlayerURL(final String page, final YoutubeExtractionOptions... options) throws Exception {
		if (page == null || page.isEmpty())
			throw new NullPointerException("page is empty!");
		String o = YouTubeExtractorConstants.find_value(page, "PLAYER_JS_URL");
		if (o == null || o.isEmpty())
			o = YouTubeExtractorConstants.find_value_alt(page, "PLAYER_JS_URL");
		if (o == null || o.isEmpty())
			o = StringsUtil.substringBetweenO(page, "\"PLAYER_JS_URL\":\"", "\",\"");
		if (o == null || o.isEmpty())
			o = YouTubeExtractorConstants.find_value(page, "player_js_url");
		if (o == null || o.isEmpty())
			o = YouTubeExtractorConstants.find_value_alt(page, "player_js_url");
		if (o == null || o.isEmpty())
			o = StringsUtil.substringBetweenO(page, "\"player_js_url\":\"", "\",\"");
		if (o != null) {
			o = o.replaceAll(PLAYER_URL_PATTERN1, "/");
			o = StringsUtil.substringBetweenO(o, "\"", "\",\"");
		}
		if (o == null)
			throw new NullPointerException("Failed to get player URL! [o == null!]");
		logger.debug("getPlayerURL::fromData-pre: " + o);
		if (o.contains("\"});"))
			o = o.substring(0, o.indexOf("\"});"));
		if (o.contains("\"});ytcfg"))
			o = o.substring(0, o.indexOf("\"});ytcfg"));
		if (o.contains("\"});"))
			o = o.substring(0, o.indexOf("\"});"));
		logger.debug("getPlayerURL::fromData: " + o);
		if (CLEAN_URL_CHARS)
			o = cleanInvalidCharacters(o);
		else
			o = o.trim();
		if (o.startsWith("https://")) {
			return o;
		} else {
			if (o.startsWith("/yts/"))
				return "https://s.ytimg.com" + o;
			if (o.startsWith("/s/"))
				return "https://www.youtube.com" + o;
			return "https://s.ytimg.com/yts/jsbin/player_" + o;
		}
	}

	private static ExtractedSignatureData cipher_v8(final String jsFile) {
		final String o = "=function(a){a=a.split(\"\");var b=[";
		final String subStringToValue = StringsUtil.substringBetweenO(jsFile.substring(jsFile.indexOf(o) + o.length()), "=function(a){a=a.split(\"\");",
				";return a.join(\"\")};");
		final int index = jsFile.indexOf(subStringToValue);
		final String r = validateFunctionName(jsFile.substring(jsFile.indexOf(o) + o.length()).substring(index - "=function(a){a=a.split(\"\");".length() - 4,
				index - "=function(a){a=a.split(\"\");".length() - 2));
		logger.debug("Function name: " + r);
		final String function = r + "=function(a){a=a.split(\"\");" + subStringToValue + ";return a.join(\"\")" + "};";
		final String subClassName = subStringToValue.substring(0, subStringToValue.indexOf('.'));
		String subClass0 = StringsUtil.substringBetweenO(jsFile, "var " + subClassName + "={", "}};");
		if (subClass0 != null) {
			subClass0 += "}";
		}
		logger.debug("Generating subclass...");
		final String subClass = "var " + subClassName + "={" + subClass0 + "};";
		logger.debug("Creating decryption script...");
		final String ds = subClass + function;
		if (ds.length() > 1024 || subClass0 == null) {
			logger.error("Couldn't get decryption function...");
			logger.error("subClass=" + subClass);
			logger.error("function=" + function);
			return null;
		}
		logger.debug(ds);
		return new ExtractedSignatureData(ds, subClass, r);
	}

	public static ExtractedSignatureData fromJSData(final String jsFile) {
		return cipher_v8(jsFile);
	}

	public final static boolean ENABLE_EXTRACTOR_RECYCLE = Arguments.hasArgument("--SignatureExtractor.useRecycle");

	public static Extractor autoExtractor(final ExtractedSignatureData data) {
		if (YouTubeExtractor.extractor.hasCachedItem() && ENABLE_EXTRACTOR_RECYCLE) {
			final Extractor extractor = YouTubeExtractor.extractor.get();
			logger.debug("[autoExtractor] Preparing to call updateESD...");
			extractor.updateESD(data);
			logger.debug("[autoExtractor] Successfully recycled extractor.");
			return extractor;
		} else {
			return extractor(data);
		}
	}

	private static Extractor _fallback(final ExtractedSignatureData data) {
		if (Arguments.hasArgument("--SignatureExtractor.enableBaseJS"))
			return new BaseJSExtractor(data);
		if (nScripting.isRhinoInstalled() && Arguments.notArguments("--SignatureExtractor.disableRhino"))
			return new RhinoBasedExtractor(data);
		if (nScripting.isGraalJSInstalled() && Arguments.notArguments("--SignatureExtractor.disableGraal"))
			return new GraalBasedExtractor(data);
		if (nScripting.isNashornInstalled() && Arguments.notArguments("--SignatureExtractor.disableNashorn"))
			return new NashornBasedExtractor(data);
		return new SoftwareExtractor(data);
	}

	public static Extractor extractor(final ExtractedSignatureData data) {
		Variables.requireNonNull(data, "data");
		return _fallback(data);
	}

	public static void main(String[] args) throws Exception {
		JVM.println("player URL: " + autoVideoID("9dCMfg6uPms"));
	}

	public static String getCachedPlayerURL() {
		return cachedPlayerURL.get();
	}

	public static boolean hasCachedPlayerURL() {
		return cachedPlayerURL.notNull();
	}

	public static String cachePlayerURL(final String playerURL) {
		if (playerURL == null || playerURL.isEmpty())
			return playerURL;
		cachedPlayerURL.set(playerURL);
		return playerURL;
	}
}
